20 Web 端连接 ROS2 与话题订阅

Web 端连接 ROS2 与话题订阅

关联:索引

要解决的问题

章节内容(本讲核心):

与前置知识衔接(避免重复):

独立项目创建与代码落位(本讲建议)

本讲的代码可以组成一个“独立可运行”的前端项目。推荐使用 Vue3 + Vite + TypeScript 模板,便于直接把中的 TypeScript 代码放进工程中运行与调试。

1) 创建项目(在你的工作目录执行)

npm create vite@latest 07_rosbridge_topic_subscriber -- --template vue-ts
cd 07_rosbridge_topic_subscriber
npm install
npm run dev

Windows(PowerShell)常见提示:如果遇到 “禁止运行脚本 npm.ps1”,优先用 npm.cmd 执行同等命令:

npm.cmd install
npm.cmd run dev

2) 推荐项目结构(相对路径)

07_rosbridge_topic_subscriber/
  src/
    main.ts
    App.vue
    components/
      RosbridgeMinimalSubscribe.vue
      RosbridgeWorkshop.vue
    utils/
      rosbridge.ts
      ros-msg-convert.ts

3) 代码应该放到哪里(相对路径)

4) 最小接线示例(确保项目一跑就能联调)

src/App.vue 改成只渲染一个演示组件(最简单、最不依赖 Router/Pinia):

<template>
  <RosbridgeMinimalSubscribe />
</template>

<script setup lang="ts">
import RosbridgeMinimalSubscribe from './components/RosbridgeMinimalSubscribe.vue'
</script>
<template>
  <RosbridgeWorkshop />
</template>

<script setup lang="ts">
import RosbridgeWorkshop from './components/RosbridgeWorkshop.vue'
</script>
<template>
  <div class="wrap">
    <h2>rosbridge 话题订阅(最小示例)</h2>

    <div class="row">
      <label class="label" for="url">WebSocket URL</label>
      <input id="url" v-model="url" class="input" type="text" />
    </div>

    <div class="row">
      <div class="status">状态:{{ status }}</div>
      <button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
        连接
      </button>
      <button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">
        断开
      </button>
    </div>

    <div class="row">
      <div class="status">订阅:{{ topic }}({{ type }})</div>
    </div>

    <div class="row">
      <div class="status">最近一条 /chatter:{{ chatterText ?? '(暂无)' }}</div>
    </div>

    <div v-if="lastRaw" class="row">
      <details>
        <summary>最近一条原始 JSON</summary>
        <pre class="pre">{{ lastRaw }}</pre>
      </details>
    </div>

    <div v-if="lastError" class="row error">
      错误:{{ lastError }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { onUnmounted, ref } from 'vue'

const url = ref('ws://localhost:9090')

type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'

const status = ref<Status>('IDLE')

const topic = '/chatter'
const type = 'std_msgs/msg/String'
const id = 'sub-chatter'

const chatterText = ref<string | null>(null)
const lastRaw = ref<string>('')
const lastError = ref<string>('')

let ws: WebSocket | null = null

function setStatusFromReadyState(sock: WebSocket): void {
  status.value =
    sock.readyState === WebSocket.CONNECTING
      ? 'CONNECTING'
      : sock.readyState === WebSocket.OPEN
      ? 'OPEN'
      : sock.readyState === WebSocket.CLOSING
      ? 'CLOSING'
      : 'CLOSED'
}

function connect(): void {
  lastError.value = ''
  chatterText.value = null
  lastRaw.value = ''

  if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
    setStatusFromReadyState(ws)
    return
  }

  ws = new WebSocket(url.value)
  setStatusFromReadyState(ws)

  ws.addEventListener('open', () => {
    if (!ws) return
    setStatusFromReadyState(ws)

    const subscribeMsg = { op: 'subscribe', topic, type, id }
    ws.send(JSON.stringify(subscribeMsg))
  })

  ws.addEventListener('message', (ev) => {
    const raw = typeof ev.data === 'string' ? ev.data : ''
    if (!raw) return

    lastRaw.value = raw

    let data: unknown
    try {
      data = JSON.parse(raw)
    } catch {
      return
    }

    if (!data || typeof data !== 'object') return
    if (!('op' in data) || !('topic' in data) || !('msg' in data)) return

    const d = data as { op: unknown; topic: unknown; msg: unknown }
    if (d.op !== 'publish' || d.topic !== topic) return

    const msg = d.msg as { data?: unknown }
    chatterText.value = typeof msg.data === 'string' ? msg.data : JSON.stringify(d.msg)
  })

  ws.addEventListener('error', () => {
    lastError.value = 'WebSocket error'
  })

  ws.addEventListener('close', (ev) => {
    status.value = 'CLOSED'
    lastError.value = lastError.value || `closed: ${ev.code} ${ev.reason || ''}`.trim()
  })
}

function disconnect(): void {
  if (!ws) return
  try {
    status.value = 'CLOSING'
    ws.close()
  } finally {
    ws = null
  }
})

onUnmounted(() => {
  disconnect()
})
</script>

<style scoped>
.wrap {
  max-width: 860px;
  margin: 24px auto;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
}

.row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}

.label {
  width: 120px;
}

.input {
  flex: 1;
  padding: 8px 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
}

.status {
  flex: 1;
}

.btn {
  padding: 8px 12px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background: #fff;
}

.pre {
  margin-top: 8px;
  padding: 12px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: auto;
}

.error {
  color: #b91c1c;
}
</style>

作业:


  1. WebSocket 连接成功:浏览器触发 open,且能看到 “connected” 日志
  2. 订阅请求发出:成功 send 了一条 op=subscribe 的 JSON
  3. 收到话题数据:浏览器 message 中能看到 topicmsg
  4. 打印关键字段:从 msg 中提取至少 1 个核心字段并打印/展示

1. 订阅请求(subscribe)最小结构

{
  "op": "subscribe",
  "topic": "/chatter",
  "type": "std_msgs/msg/String",
  "id": "sub-chatter"
}

2. 服务端推送的数据结构(你在 onmessage 会收到)

{
  "op": "publish",
  "topic": "/chatter",
  "msg": {
    "data": "hello world"
  }
}

3. 取消订阅(unsubscribe)

{
  "op": "unsubscribe",
  "topic": "/chatter",
  "id": "sub-chatter"
}

1. 快速启动一个持续发布的话题(示例:/chatter)

在 ROS2 终端执行(任选其一):

ros2 run demo_nodes_cpp talker

或(更可控的发布频率):

ros2 topic pub -r 5 /chatter std_msgs/msg/String "{data: 'hello from ros2'}"

2. 自检:确认话题确实在跑

ros2 topic list
ros2 topic echo /chatter --once

本节先用“纯 WebSocket + JSON”打通闭环,不依赖额外库,便于你理解协议本质。

1. 最小 TypeScript 示例(可直接放到任意 Vue3 组件的 <script setup lang="ts"> 中执行)

const url = 'ws://localhost:9090'

const ws = new WebSocket(url)

ws.addEventListener('open', () => {
  console.log('[rosbridge] connected:', url)

  const subscribeMsg = {
    op: 'subscribe',
    topic: '/chatter',
    type: 'std_msgs/msg/String',
    id: 'sub-chatter'
  }

  ws.send(JSON.stringify(subscribeMsg))
  console.log('[rosbridge] subscribe sent:', subscribeMsg)
})

ws.addEventListener('message', (ev) => {
  const raw = typeof ev.data === 'string' ? ev.data : ''
  if (!raw) return

  let data: unknown
  try {
    data = JSON.parse(raw)
  } catch {
    console.warn('[rosbridge] invalid json:', raw)
    return
  }

  if (
    typeof data === 'object' &&
    data !== null &&
    'op' in data &&
    'topic' in data &&
    'msg' in data
  ) {
    const d = data as { op: unknown; topic: unknown; msg: unknown }
    if (d.op === 'publish' && d.topic === '/chatter') {
      const msg = d.msg as { data?: unknown }
      console.log('[topic:/chatter]', msg.data)
    }
  }
})

ws.addEventListener('error', () => {
  console.error('[rosbridge] ws error')
})

ws.addEventListener('close', (ev) => {
  console.warn('[rosbridge] closed:', ev.code, ev.reason)
})

五、练习(至少 2 题)

  1. 把订阅从 /chatter 改为你环境里的另一个话题,并打印其中一个关键字段(例如电压、电流、温度、状态码)。
  2. 写出一段“解析失败的容错策略”:当 JSON 解析失败或字段缺失时,你要打印什么信息,才能最快定位问题?

七、学生任务(提交物与标准)

八、大模型任务(给 AI 的指令模板 + 校验点)

提示词(直接复制给 AI):

请用 TypeScript 写一个“rosbridge WebSocket 最小订阅”示例,要求:
1) 可配置 ws 地址(默认 ws://localhost:9090)
2) 连接成功后发送 subscribe:op/topic/type/id 必须齐全
3) onmessage 中做 JSON.parse,并根据 topic 路由处理
4) 至少打印 /chatter 的 msg.data
5) 必须包含 error/close 的处理日志
输出:完整代码 + 每段关键逻辑解释

校验点(你必须人工检查):

如果把所有逻辑都写在一个组件里,通常会遇到:

export type RosbridgePublish<TMsg = unknown> = {
  op: 'publish'
  topic: string
  msg: TMsg
}

export type RosbridgeSubscribe = {
  op: 'subscribe'
  topic: string
  type: string
  id: string
  throttle_rate?: number
  queue_length?: number
}

export type RosbridgeUnsubscribe = {
  op: 'unsubscribe'
  topic: string
  id: string
}

function safeJsonParse(text: string): unknown {
  try {
    return JSON.parse(text)
  } catch {
    return null
  }
}

function makeSubId(topic: string): string {
  const normalized = topic.replace(/^\//, '').replace(/\//g, '-')
  return `sub-${normalized}`
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

export class RosbridgeClient {
  private ws: WebSocket | null = null
  private readonly handlers = new Map<string, (msg: unknown) => void>()
  private readonly url: string

  constructor(url: string) {
    this.url = url
  }

  connect(): void {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) return
    if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return

    this.ws = new WebSocket(this.url)

    this.ws.addEventListener('open', () => {
      console.log('[rosbridge] connected:', this.url)
    })

    this.ws.addEventListener('message', (ev) => {
      const raw = typeof ev.data === 'string' ? ev.data : ''
      if (!raw) return

      const data = safeJsonParse(raw)
      if (!data || typeof data !== 'object') return

      if (!('op' in data) || !('topic' in data) || !('msg' in data)) return
      const d = data as { op: unknown; topic: unknown; msg: unknown }

      if (d.op !== 'publish' || typeof d.topic !== 'string') return

      const handler = this.handlers.get(d.topic)
      if (!handler) return

      handler(d.msg)
    })

    this.ws.addEventListener('error', () => {
      console.error('[rosbridge] ws error')
    })

    this.ws.addEventListener('close', (ev) => {
      console.warn('[rosbridge] closed:', ev.code, ev.reason)
    })
  }

  async waitForOpen(timeoutMs = 5000): Promise<void> {
    const start = Date.now()
    while (true) {
      const state = this.ws?.readyState
      if (state === WebSocket.OPEN) return
      if (state === WebSocket.CLOSING || state === WebSocket.CLOSED) {
        throw new Error('WebSocket closed before OPEN')
      }
      if (Date.now() - start > timeoutMs) {
        throw new Error(`Timeout waiting for WebSocket OPEN (${timeoutMs}ms)`)
      }
      await sleep(50)
    }
  }

  disconnect(): void {
    if (!this.ws) return
    try {
      this.ws.close()
    } finally {
      this.ws = null
      this.handlers.clear()
    }
  }

  subscribe(topic: string, type: string, handler: (msg: unknown) => void): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      throw new Error('WebSocket is not OPEN. Call connect() and wait for open.')
    }

    const id = makeSubId(topic)
    const req: RosbridgeSubscribe = { op: 'subscribe', topic, type, id }
    this.ws.send(JSON.stringify(req))
    this.handlers.set(topic, handler)
    console.log('[rosbridge] subscribed:', req)
  }

  unsubscribe(topic: string): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return

    const id = makeSubId(topic)
    const req: RosbridgeUnsubscribe = { op: 'unsubscribe', topic, id }
    this.ws.send(JSON.stringify(req))
    this.handlers.delete(topic)
    console.log('[rosbridge] unsubscribed:', req)
  }
}

2. 自检点(必须当场过一遍)

  1. 连接 open 后再 subscribe(否则会抛错或 silent fail)
  2. subscribe 里的 type 字符串与 ROS2 消息一致(例如 std_msgs/msg/String
  3. message 中只处理 op=publish,不要把其他 op 当成数据(避免误判)

本节的“格式转换”指两层:

  1. unknown → TS 接口(校验字段存在性与基本类型)
  2. TS 接口 → 页面可用数据(例如挑关键字段、单位转换、数组统计等)

1. 示例 A:std_msgs/msg/String(/chatter)

export type StdStringMsg = { data: string }

export function toStdStringMsg(input: unknown): StdStringMsg | null {
  if (!input || typeof input !== 'object') return null
  if (!('data' in input)) return null
  const data = (input as { data: unknown }).data
  return typeof data === 'string' ? { data } : null
}

2. 示例 B:sensor_msgs/msg/LaserScan(传感器话题常见)

export type LaserScanMsg = {
  angle_min: number
  angle_max: number
  angle_increment: number
  ranges: number[]
}

export type LaserScanView = {
  minRange: number
  maxRange: number
  validCount: number
}

export function toLaserScanMsg(input: unknown): LaserScanMsg | null {
  if (!input || typeof input !== 'object') return null
  const x = input as Record<string, unknown>

  const angle_min = x.angle_min
  const angle_max = x.angle_max
  const angle_increment = x.angle_increment
  const ranges = x.ranges

  if (
    typeof angle_min !== 'number' ||
    typeof angle_max !== 'number' ||
    typeof angle_increment !== 'number' ||
    !Array.isArray(ranges)
  ) {
    return null
  }

  const numericRanges = ranges.filter((v): v is number => typeof v === 'number')
  return { angle_min, angle_max, angle_increment, ranges: numericRanges }
}

export function toLaserScanView(msg: LaserScanMsg): LaserScanView {
  const finite = msg.ranges.filter((v) => Number.isFinite(v))
  if (finite.length === 0) return { minRange: NaN, maxRange: NaN, validCount: 0 }

  let minRange = finite[0]
  let maxRange = finite[0]
  for (const v of finite) {
    if (v < minRange) minRange = v
    if (v > maxRange) maxRange = v
  }

  return { minRange, maxRange, validCount: finite.length }
}

参考实现:src/components/RosbridgeWorkshop.vue(可直接运行)

<template>
  <div class="wrap">
    <h2>项目工坊:多话题订阅与格式转换</h2>

    <div class="row">
      <label class="label" for="url">WebSocket URL</label>
      <input id="url" v-model="url" class="input" type="text" />
    </div>

    <div class="row">
      <div class="status">连接状态:{{ status }}</div>
      <button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
        连接
      </button>
      <button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">
        断开
      </button>
    </div>

    <div class="row">
      <div class="status">/chatter:{{ chatterText ?? '(暂无)' }}</div>
      <button class="btn" type="button" :disabled="status !== 'OPEN'" @click="toggleChatter">
        {{ chatterSubscribed ? '取消订阅' : '订阅' }}
      </button>
    </div>

    <div class="row">
      <div class="status">
        /scan(LaserScan)统计:min={{ scanView?.minRange ?? '—' }} max={{ scanView?.maxRange ?? '—' }}
        count={{ scanView?.validCount ?? '—' }}
      </div>
      <button class="btn" type="button" :disabled="status !== 'OPEN'" @click="toggleScan">
        {{ scanSubscribed ? '取消订阅' : '订阅' }}
      </button>
    </div>

    <div v-if="lastError" class="row error">
      错误:{{ lastError }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
import { RosbridgeClient } from '../utils/rosbridge'
import { toLaserScanMsg, toLaserScanView, toStdStringMsg, type LaserScanView } from '../utils/ros-msg-convert'

type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED'

const url = ref('ws://localhost:9090')
const status = ref<Status>('IDLE')
const lastError = ref('')

const chatterText = ref<string | null>(null)
const chatterSubscribed = ref(false)

const scanView = ref<LaserScanView | null>(null)
const scanSubscribed = ref(false)

let client: RosbridgeClient | null = null

async function connect(): Promise<void> {
  lastError.value = ''
  status.value = 'CONNECTING'

  client = new RosbridgeClient(url.value)
  client.connect()

  try {
    await client.waitForOpen(5000)
    status.value = 'OPEN'
  } catch (e) {
    status.value = 'CLOSED'
    lastError.value = e instanceof Error ? e.message : 'connect failed'
  }
}

function disconnect(): void {
  client?.disconnect()
  client = null
  status.value = 'CLOSED'
  chatterSubscribed.value = false
  scanSubscribed.value = false
}

function toggleChatter(): void {
  if (!client) return
  if (!chatterSubscribed.value) {
    client.subscribe('/chatter', 'std_msgs/msg/String', (msg) => {
      const parsed = toStdStringMsg(msg)
      chatterText.value = parsed ? parsed.data : JSON.stringify(msg)
    })
    chatterSubscribed.value = true
    return
  }

  client.unsubscribe('/chatter')
  chatterSubscribed.value = false
}

function toggleScan(): void {
  if (!client) return
  if (!scanSubscribed.value) {
    client.subscribe('/scan', 'sensor_msgs/msg/LaserScan', (msg) => {
      const parsed = toLaserScanMsg(msg)
      scanView.value = parsed ? toLaserScanView(parsed) : null
    })
    scanSubscribed.value = true
    return
  }

  client.unsubscribe('/scan')
  scanSubscribed.value = false
}

onUnmounted(() => {
  disconnect()
})
</script>

<style scoped>
.wrap {
  max-width: 860px;
  margin: 24px auto;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
}

.row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}

.label {
  width: 120px;
}

.input {
  flex: 1;
  padding: 8px 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
}

.status {
  flex: 1;
}

.btn {
  padding: 8px 12px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background: #fff;
}

.error {
  color: #b91c1c;
}
</style>

提示词(直接复制给 AI):

我要在 Vue3 + Vite + TypeScript 项目里订阅 ROS2 话题(通过 rosbridge WebSocket)。
请输出:
1) 一个 RosbridgeClient 封装(connect/disconnect/subscribe/unsubscribe)
2) subscribe 使用 rosbridge JSON 协议(op/topic/type/id),message 里只处理 op=publish
3) 示例:订阅 /chatter(std_msgs/msg/String),打印 msg.data
4) 可选:给一个 LaserScan(sensor_msgs/msg/LaserScan)的字段校验与转换示例(提取 ranges 的 min/max)
要求:代码可直接粘贴运行,并解释关键逻辑与常见坑。

校验点(你必须人工检查):

Markdown 与代码自检(提交前必做)

  1. 代码块语言标签完整(bash/json/ts),且三引号成对闭合
  2. subscribe/unsubscribe 的 JSON 字段名全部正确(op/topic/type/id)
  3. Web 端发送前必做 JSON.stringify,接收时先 JSON.parse 再校验字段
  4. TS 转换函数不直接信任 unknown,解析失败返回 null 并由上层降级处理
Connected Pages
On this page
Web 端连接 ROS2 与话题订阅 要解决的问题
  • 章节内容(本讲核心):
  • 与前置知识衔接(避免重复):
  • 独立项目创建与代码落位(本讲建议)
    1. 1) 创建项目(在你的工作目录执行)
    2. 2) 推荐项目结构(相对路径)
    3. 3) 代码应该放到哪里(相对路径)
    4. 4) 最小接线示例(确保项目一跑就能联调)
  • 作业:
  • 1. 订阅请求(subscribe)最小结构
  • 2. 服务端推送的数据结构(你在 onmessage 会收到)
  • 3. 取消订阅(unsubscribe)
  • 1. 快速启动一个持续发布的话题(示例:/chatter)
  • 2. 自检:确认话题确实在跑
  • 1. 最小 TypeScript 示例(可直接放到任意 Vue3 组件的